Skip to content

feat(loans): native amortization schedule for fixed-rate loans (#1804)#1887

Open
Rene0422 wants to merge 4 commits into
we-promise:mainfrom
Rene0422:feat/loan-amortization-schedule
Open

feat(loans): native amortization schedule for fixed-rate loans (#1804)#1887
Rene0422 wants to merge 4 commits into
we-promise:mainfrom
Rene0422:feat/loan-amortization-schedule

Conversation

@Rene0422
Copy link
Copy Markdown

@Rene0422 Rene0422 commented May 20, 2026

Summary

Implements the schedule half of #1804. Fixed-rate loan accounts now expose a French-style amortization schedule, total interest, total payments, and projected payoff date — surfaced as two new summary cards and a collapsible table on the loan's Overview tab.

Closes the data/visibility gap users hit today: they no longer need an external amortization table to see how each monthly payment splits between principal and interest.

What's included

  • Loan#amortization_schedule — array of { period, payment_date, beginning_balance, payment, interest, principal, ending_balance }, memoized per instance
  • Loan#total_interest, #total_payments, #payoff_date
  • Returns [] / nil for non-fixed rate loans, missing term_months / interest_rate, or zero principal
  • Final-period rounding correction so the loan zeroes out exactly
  • Overview tab: two new summary cards + <details> schedule table (Hotwire-first, no JS)
  • i18n keys added to en.yml; non-English locales fall back via config.i18n.fallbacks
  • 8 new unit tests in test/models/loan_test.rb

What's deliberately not included

Auto-splitting incoming loan payments into a principal transfer + interest expense — the other half of #1804. That change touches Transaction creation, provider sync (Plaid / SimpleFIN), idempotency, and undo/edit semantics around extra-principal payments. It warrants its own design discussion and PR. Filed as a follow-up.

Test plan

  • bin/rails test test/models/loan_test.rb passes
  • bin/rails test passes
  • bin/rubocop -f github -a clean
  • bundle exec erb_lint ./app/**/*.erb -a clean
  • bin/brakeman --no-pager clean
  • Manually verify on a fixed-rate mortgage account: summary cards show Total Interest + Payoff Date; <details> table expands to 360 rows for a 30-year loan; final-row Balance is $0.00
  • Verify variable / adjustable / under-configured loans: new cards show "Unknown" and the table is hidden
  • Verify zero-interest loan: schedule renders with $0 interest column and equal principal across all rows

Summary by CodeRabbit

  • New Features
    • Added a memoized loan amortization schedule with per-period payment, interest/principal split, and remaining balance; overview shows Total Interest and Payoff Date and conditionally reveals a collapsible, horizontally scrollable upcoming-schedule table with formatted dates and currency.
    • Monthly payment now returns zero for zero balance or zero term and uses the same rounding used for schedules.
  • Documentation
    • Added translations/labels for amortization schedule fields and summaries.
  • Tests
    • Added tests for schedule generation, zero-interest and non-fixed cases, totals, and payoff date.

Review Change Stack

@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 20, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a memoized fixed-rate amortization schedule with aggregation helpers and payoff_date to Loan, displays total interest/payoff and a collapsible amortization table in the loan overview, introduces locale keys for schedule labels, and adds tests verifying schedule correctness and edge cases.

Changes

Loan Amortization Schedule Implementation

Layer / File(s) Summary
Amortization Schedule Calculation
app/models/loan.rb
Public methods amortization_schedule, total_interest, total_payments, and payoff_date expose fixed-rate schedule projections. Private build_amortization_schedule computes monthly periods with interest/principal split, 2-decimal rounding per period, and final-period drift absorption. Helpers exact_monthly_payment and schedule_start_date are added.
Loan Overview UI & Translations
app/views/loans/tabs/_overview.html.erb, config/locales/views/loans/en.yml
Adds Total Interest and Payoff Date summary cards (with unknown fallback). Conditionally renders an amortization schedule disclosure of upcoming rows with per-period payment date, payment, principal, interest, and ending balance. Adds locale keys for schedule labels and total_interest.
Test Coverage & Fixtures
test/models/loan_test.rb
Introduces build_loan_account test helper, refactors monthly payment test, and adds tests covering schedule row counts, period bounds, interest/principal splits, balance consistency, final zero balance, zero-interest handling, empty schedule conditions for non-fixed/missing attributes, aggregation of total_interest, and payoff_date correctness.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • jjmata

Poem

🐰 I count the months with twitching nose,
Interest hops down where principal goes,
Rounding tucked neat in the final tide,
Balances fall until zero resides,
I nibble a carrot and watch the schedule glide.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(loans): native amortization schedule for fixed-rate loans' clearly and specifically summarizes the main change—adding a native amortization schedule feature for fixed-rate loans, which is the primary focus of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49216c4829

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread app/views/loans/tabs/_overview.html.erb Outdated
Comment on lines +65 to +67
<details class="mt-6 rounded-lg border border-primary bg-container">
<summary class="cursor-pointer select-none px-4 py-3 text-sm font-medium text-primary">
<%= t(".amortization_schedule") %>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace hand-rolled disclosure with DS::Disclosure

This view introduces a custom <details>/<summary> disclosure instead of the design-system primitive, which violates the repository rule in /workspace/sure/AGENTS.md (“Reach for DS::* first” and specifically lists DS::Disclosure). Shipping bespoke disclosure markup here means behavior/styling/accessibility fixes made in DS::Disclosure will not propagate to loans, and this guideline is explicitly marked as a close/rewrite-level issue for reviewers.

Useful? React with 👍 / 👎.

Comment thread app/models/loan.rb
Comment on lines +95 to +99
if period == term_months || principal_amount > balance
principal_amount = balance.round(2)
end

payment_amount = (interest_amount + principal_amount).round(2)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop generating zero-payment rows after payoff

When rounding causes the balance to hit zero before the final term, the loop keeps emitting remaining periods with $0 payment/interest/principal instead of ending the schedule. Because payoff_date is taken from the last row, this can push the reported payoff later than the actual payoff month (e.g., small principals with long terms, such as balance=1000, interest_rate=9.4, term_months=360, reach zero at period 359 but still add period 360 as all zeros).

Useful? React with 👍 / 👎.

@Rene0422 Rene0422 force-pushed the feat/loan-amortization-schedule branch from 4bc8578 to c00a8f8 Compare May 21, 2026 00:45
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/models/loan_test.rb`:
- Around line 34-35: The test currently does an exact float equality
assert_equal on the computed balance using first[:beginning_balance].amount,
first[:principal].amount, and first[:ending_balance].amount which can be flaky;
change the assertion in test/models/loan_test.rb to use assert_in_delta with a
small tolerance (e.g. 0.01 or 1e-6) comparing
(first[:beginning_balance].amount.to_f - first[:principal].amount.to_f) and
first[:ending_balance].amount.to_f so tiny floating-point rounding differences
won't fail the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c5e908fb-f013-4843-9b3b-8e36f8917764

📥 Commits

Reviewing files that changed from the base of the PR and between 49216c4 and c00a8f8.

📒 Files selected for processing (4)
  • app/models/loan.rb
  • app/views/loans/tabs/_overview.html.erb
  • config/locales/views/loans/en.yml
  • test/models/loan_test.rb
✅ Files skipped from review due to trivial changes (1)
  • config/locales/views/loans/en.yml

Comment thread test/models/loan_test.rb Outdated
Copy link
Copy Markdown
Collaborator

jjmata commented May 21, 2026

Nice feature, good test coverage. A few things worth discussing before merge:

1. Floating-point arithmetic for financial calculations. build_amortization_schedule does all math in Float (principal.to_f, (balance * monthly_rate).round(2), etc.) before wrapping in Money. Over 360 payments, floating-point rounding accumulates and the final-period correction (principal_amount = balance.round(2)) absorbs the drift, which is pragmatic — but BigDecimal is the safer choice for financial calculations. The codebase wraps values in Money, so it presumably expects integers (cents), not floats. Worth verifying that Money.new(1200.0, "USD") behaves correctly and consistently.

2. Schedule start date produces historical dates. schedule_start_date uses account.first_valuation&.date, which for an existing mortgage could be years in the past. This means the amortization table will show hundreds of already-past payment rows before the "current" position in the schedule. Is this intentional (full historical schedule) or should the schedule start from a near-present date? The payoff date is correct either way, but the UX of a 30-year mortgage showing rows dating back to 2010 may be surprising.

3. Raw <details>/<summary> in the view. The codebase is actively migrating provider disclosure patterns to DS::Disclosure (PRs #1855#1858). Using a raw <details> here means this will need to be migrated again later, and it won't pick up focus-ring, motion-safe, or accessibility improvements that land in the component. Consider using DS::Disclosure from the start.

4. Performance on long schedules. The schedule is memoized per instance (good for a single request), but 360-row generation happens on every page load for a 30-year loan. This is fine at current scale, but if this is called in list views or anywhere the loan object is iterated, it'll get expensive. Confirming it's only called on the individual loan detail page is sufficient.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/views/loans/tabs/_overview.html.erb (1)

63-65: ⚡ Quick win

Move the upcoming-row filtering out of the template.

Selecting future rows with Date.current is domain/data-shaping logic in the view. Expose an upcoming_amortization_schedule method/helper and keep this partial focused on rendering.

As per coding guidelines, "Keep domain logic out of views: compute values like button classes, conditional logic, and data transformations in the component file, not the template file".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/views/loans/tabs/_overview.html.erb` around lines 63 - 65, The view
currently performs domain/data-shaping by computing upcoming_schedule via
account.loan.amortization_schedule.select { |row| row[:payment_date] >=
Date.current }; move that filtering into a helper or model method (e.g., define
upcoming_amortization_schedule on the Loan model or a view helper) and replace
the inline select in the partial with a call to that method (use the new
upcoming_amortization_schedule or helper name), keeping the partial responsible
only for rendering and not for date-based selection logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/loan.rb`:
- Around line 88-89: The monthly payment and schedule are computed on two
different code paths causing cent-level mismatches; update the implementation so
one method delegates to the other — e.g., change Loan#monthly_payment to call
exact_monthly_payment(principal, monthly_rate) (or have
build_amortization_schedule call monthly_payment) so both use the same
calculation and rounding logic; ensure you compute monthly_rate the same way
(interest_rate.to_d / 100).div(12, DIVISION_PRECISION) and reuse that value, and
apply this same delegation fix for the related methods around the other
occurrence (lines handling the schedule calculation) so summary and schedule
always match.

---

Nitpick comments:
In `@app/views/loans/tabs/_overview.html.erb`:
- Around line 63-65: The view currently performs domain/data-shaping by
computing upcoming_schedule via account.loan.amortization_schedule.select {
|row| row[:payment_date] >= Date.current }; move that filtering into a helper or
model method (e.g., define upcoming_amortization_schedule on the Loan model or a
view helper) and replace the inline select in the partial with a call to that
method (use the new upcoming_amortization_schedule or helper name), keeping the
partial responsible only for rendering and not for date-based selection logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 815def02-4c85-47da-99e1-664b1168802e

📥 Commits

Reviewing files that changed from the base of the PR and between c5e1463 and 8c60092.

📒 Files selected for processing (2)
  • app/models/loan.rb
  • app/views/loans/tabs/_overview.html.erb

Comment thread app/models/loan.rb Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/models/loan_test.rb (1)

32-32: 💤 Low value

Consider using assert_in_delta for consistency with other float assertions.

Line 32 uses exact equality on a float, while lines 33-35 correctly use assert_in_delta. Although 1200.0 is exactly representable in IEEE 754, using the delta pattern consistently makes the test suite more uniform and prevents copy-paste issues for future tests with non-exact values.

Suggested change
-    assert_equal 1200.00, first[:interest].amount.to_f
+    assert_in_delta 1200.00, first[:interest].amount.to_f, 0.01
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/loan_test.rb` at line 32, Replace the exact-float assertion with
a delta-based assertion: change the assertion that compares
first[:interest].amount.to_f to 1200.00 from assert_equal to assert_in_delta and
supply the same small delta used elsewhere in this test file (e.g., 0.001) so it
reads like: assert_in_delta 1200.00, first[:interest].amount.to_f, 0.001.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/models/loan_test.rb`:
- Line 32: Replace the exact-float assertion with a delta-based assertion:
change the assertion that compares first[:interest].amount.to_f to 1200.00 from
assert_equal to assert_in_delta and supply the same small delta used elsewhere
in this test file (e.g., 0.001) so it reads like: assert_in_delta 1200.00,
first[:interest].amount.to_f, 0.001.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 099523d1-a85d-41d4-8207-ec1dbe6a0704

📥 Commits

Reviewing files that changed from the base of the PR and between 8c60092 and 0783cbc.

📒 Files selected for processing (2)
  • app/models/loan.rb
  • test/models/loan_test.rb

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants